Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨ Support Nuget Central Package Management #4369

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

balteravishay
Copy link
Contributor

What kind of change does this PR introduce?

Support nuget central package management in detecting pinned dependencies

What is the current behavior?

Today, scorecard only supported nuget pinning dependencies with lockfiles. However, nuget provides package-manager-level assurance that when enabling CPM AND declaring specific versions for all direct dependencies the same level of pinning is provided to the end-user.

What is the new behavior (if this is a feature change)?**

Scorecard will check if Directory.*.props file is present and both:
a. ManagePackageVersionsCentrally attribute is set to true
b. all direct dependencies have a specific version declared

if both are true, all unpinned dependencies will be marked as pinned.

  • Tests for the changes have been added (for bug fixes/features)

Which issue(s) this PR fixes

Fixes #4252

Special notes for your reviewer

This was discussed in a community call with @spencerschrock and it continues the work that was started in PR #4351

Here is a repo that would be found as pinned by this new checks: foesmm/WolvenKit since it has a github workflow with dotnet restore (without lockfile flag) but declares CPM and all dependencies are pinned to specific versions in the properties file

Does this PR introduce a user-facing change?

-->

NONE

Co-authored-by: Liam Moat <[email protected]>
Co-authored-by: Ioana A <[email protected]>
Co-authored-by: Mélanie Guittet <[email protected]>

Signed-off-by: balteravishay <[email protected]>
@balteravishay balteravishay requested a review from a team as a code owner October 6, 2024 18:59
@balteravishay balteravishay requested review from justaugustus and raghavkaul and removed request for a team October 6, 2024 18:59
Signed-off-by: balteravishay <[email protected]>
@spencerschrock
Copy link
Member

all direct dependencies have a specific version declared

I seem to remember listing specific versions was considered pinned due to some immutable property of the nuget package manager? (Similar to how the Go ecosystem has a checksum database?) Is there a good link for that? I found https://devblogs.microsoft.com/nuget/nuget-package-signing/, but it seems to be a few years old, and didn't know what the default policy of the tool was these days.

@JonDouglas
Copy link

@jeffkl could you advise on this PR on supporting CPM in this project? 😄

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it matters in this context, but be aware that the official name for the CPM file is Directory.Packages.props and on case sensitive file systems, it won't get imported if the case of the file name is incorrect. All of these files in testdata have a lowercase p. I'm not familiar enough with this codebase to say for sure if it will matter.

<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="SomePackage" Version="4.4.5" />
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Package versions are not pinned just because a version is declared. NuGet just uses this version specified when it comes across a <PackageReference Include="SomePackage" /> and emits an error if you specified a version in a project.

To truly "pin" a transitive package version that you do not have a direct <PackageReference /> for, you have to enable transitive pinning: https://learn.microsoft.com/nuget/consume-packages/Central-Package-Management#transitive-pinning

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, I was wrong what "pinning" meant in this context.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We define "pinned" in a reproducibility sense: "a dependency that is explicitly set to a specific hash instead of
allowing a mutable version or range of versions". Which Avishay added support for via nuget --locked-mode, or the RestoreLockedMode property .

The NuGet claim in the PR description is that:

Today, scorecard only supported nuget pinning dependencies with lockfiles. However, nuget provides package-manager-level assurance that when enabling CPM AND declaring specific versions for all direct dependencies the same level of pinning is provided to the end-user.

Which to my limited understanding, is based on the package immutability nuget.org provides. But I'm not sure that fully covers all scenarios mentioned in the lockfile blog post.

Copy link
Contributor Author

@balteravishay balteravishay Oct 21, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@spencerschrock, could you please elaborate which scenarios mentioned in the lockfile blog post you are referring to as missing from this PR:

  • nuget.config mismatch: is covered with CPM, since CPM suggests that packages are managed centrally and the user gets warnings when using multiple package sources
  • Intermediate versions and Floating versions - This PR checks for specifying specific versions and will deem floating versions as "unpinned"
  • Package deletion is granted by nuget
  • Package content mismatch is granted by Package Immutability/signing

Maybe, indeed, something that is not provided via CPM and direct dependency version check, as proposed by this PR, is transitive dependency pinning (as proposed by @jeffkl ). I can add that to check to the PR if you feel that would make it make all the requirements.

Copy link
Member

@spencerschrock spencerschrock Nov 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've been out of the office on personal time, will take a look in the next day or two.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • Day 2: Version 4.0.0 gets published. NuGet now restores version 4.0.0 as it is an exact match.

Typically, we see folks publishing higher versions of their packages as time goes by, not lower versions.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be helpful to create documentation that explains the different levels/scenarios of "pinned" dependency checks, with clear examples for each scenario. For instance, detailing how an exact version match qualifies as "pinned," a lock file represents "locked," and so forth, to ensure clarity across all cases.

Most of our documentation uses "pinned" to refer to lock files, so we can certainly try to clarify "pinned" vs "locked".

I did some digging into the history of the check, as well as checking with relevant contributors. It started off as Frozen-Deps, which looked for lockfiles., before being renamed Pinned-Dependencies. Most of the check is focused on lockfiles and immutable hashes (OCI or GitHub), but there is some pinning recognized for package managers with immutable versions (Go and Nuget).

In practice, people pin versions that actually exist

Typically, we see folks publishing higher versions of their packages as time goes by, not lower versions.

I recognize this is an edge case we probably don't need to worry about. I was just highlighting one difference of lockfiles and version pinning, as I clarified the semantics of the check.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To truly "pin" a transitive package version that you do not have a direct for, you have to enable transitive pinning

Is this because one of your direct dependencies may declare a range for one of their dependencies?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be helpful to create documentation that explains the different levels/scenarios of "pinned" dependency checks, with clear examples for each scenario. For instance, detailing how an exact version match qualifies as "pinned," a lock file represents "locked," and so forth, to ensure clarity across all cases.

Most of our documentation uses "pinned" to refer to lock files, so we can certainly try to clarify "pinned" vs "locked".

I did some digging into the history of the check, as well as checking with relevant contributors. It started off as Frozen-Deps, which looked for lockfiles., before being renamed Pinned-Dependencies. Most of the check is focused on lockfiles and immutable hashes (OCI or GitHub), but there is some pinning recognized for package managers with immutable versions (Go and Nuget).

In practice, people pin versions that actually exist

Typically, we see folks publishing higher versions of their packages as time goes by, not lower versions.

I recognize this is an edge case we probably don't need to worry about. I was just highlighting one difference of lockfiles and version pinning, as I clarified the semantics of the check.

thanks! idk who owns the naming but might be worth calling this out in a future meeting.

should package managers provide basic support for the definition of pinned dependencies I provided earlier and what's in the docs? (i.e. exact version)? or is the expectation to contribute to the "locked" only definition?

lock files aren't that prevalent in NuGet/.NET. this means that the .NET/NuGet ecosystem would score very low:

image

it seems like this check might benefit from partial points for exact versions and full points for locked/frozen/etc deps?

or perhaps a new check for "pinned" / "locked" package dependencies would be needed to take all this into account to separate from the CI/CD & docker checks?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally, the transitive graph is resolved for you based on what the packages you consume have declared. To "pin" a transitive package to a higher version, you just add a direct dependency on it via <PackageReference Include="SomeTransitivePackage" />. With CPM and transitive pinning, NuGet just does this for you during graph resolution without you having to declare the direct dependency.

Comment on lines +104 to +112
func collectPostProcessNugetCPMDependencies(unpinnedNugetDependencies []*checker.Dependency,
postProcessingData *NugetPostProcessData,
) {
packageVersions := postProcessingData.CpmConfig.PackageVersions

numFixedVersions, unfixedVersions := countFixedVersions(packageVersions)
// if all dependencies are fixed to specific versions, pin all dependencies
if numFixedVersions == len(packageVersions) {
pinAllNugetDependencies(unpinnedNugetDependencies)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is CPM all-or-nothing for managing (direct) dependencies, or can you manage only a subset centrally? For example, can you pin foo in a Directory.Packages.props and then have a project with packages foo (with no version because CPM) and bar (with a version range)? As written, this PR may give this "pinned" status even though bar can float.

I want to make sure I understand the feature. CPM is just a configuration feature so you don't need to specify versions in more than one place? The reason I'm asking is to see if any of the rules (pinned to specific version) should also apply to all project files?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CPM is all or nothing for an individual project. By enabling it in Directory.Packages.props its on for all projects but individual projects can still opt-out of it. Once enabled, you'll receive an error if any PackageReference declares a version or if any PackageReference has no corresponding PackageVersion item.

@balteravishay
Copy link
Contributor Author

hey @spencerschrock, thanks for your feedback! any other questions about CPM or can we start the review of this PR?

@spencerschrock
Copy link
Member

hey @spencerschrock, thanks for your feedback! any other questions about CPM or can we start the review of this PR?

Yep my questions are answered (thanks everyone), I'll get started on the review when I get some time.

Comment on lines 41 to +44

type NugetPostProcessData struct {
CsprojConfigs []dotnetCsprojLockedData
CpmConfig properties.CentralPackageManagementConfig
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not export this type

type nugetPostProcessData struct {

Comment on lines +87 to +101
if unpinnedDependencies := getUnpinnedNugetDependencies(pinningDependenciesData); len(unpinnedDependencies) > 0 {
var nugetPostProcessData NugetPostProcessData
if err := retrieveNugetCentralPackageManagement(c, &nugetPostProcessData); err != nil {
return err
}
if err := retrieveCsprojConfig(c, &nugetPostProcessData); err != nil {
return err
}
if nugetPostProcessData.CpmConfig.IsCPMEnabled {
collectPostProcessNugetCPMDependencies(unpinnedDependencies, &nugetPostProcessData)
} else {
collectPostProcessNugetCsprojDependencies(unpinnedDependencies, &nugetPostProcessData)
}
}
return nil
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

optional: in go it's common to return early to reduce nesting: https://danp.net/posts/reducing-go-nesting/

so you could do something like this if you want (but I won't block on it):

unpinnedDependencies := getUnpinnedNugetDependencies(pinningDependenciesData)
if len(unpinnedDependencies) == 0 {
	return nil
}

// the rest of the function

Comment on lines +123 to +124
if err := fileparser.OnMatchingFileContentDo(c.RepoClient, fileparser.PathMatcher{
Pattern: "Directory.*.props",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Jeff's comment matter here? If you have the pattern broad for the purposes of the test harness, I can help with that.

I'm not sure if it matters in this context, but be aware that the official name for the CPM file is Directory.Packages.props and on case sensitive file systems, it won't get imported if the case of the file name is incorrect.

Comment on lines +262 to +263
func countFixedVersions(packages []properties.NugetPackage) (int, string) {
var unfixedVersions []string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: this counts unfixed versions

Comment on lines +109 to +111
numFixedVersions, unfixedVersions := countFixedVersions(packageVersions)
// if all dependencies are fixed to specific versions, pin all dependencies
if numFixedVersions == len(packageVersions) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the variable is called numFixedVersions but it should really be numUnfixedVersions ? this logic needs fixed

Comment on lines +37 to +38
{"fixed version range", "[1.0]", true},
{"version as variable", "$(ComponentDetectionPackageVersion)", true},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you clarify the version as a variable result? do we know it's fixed?

Comment on lines +28 to +29
{"fixed version", "10.1.1", true},
{"fixed beta version", "10.1.1-beta", true},
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

according to your link in the regex comment, this is equivalent to "Minimum version, inclusive"?

so this conflicts with some of your tests below?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: No status
Development

Successfully merging this pull request may close these issues.

BUG: .Net pinned dependency should support Central Package Management
5 participants